Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 0e86b574edc3ff25cf300c66ed0076bbd1b383df


Parents : b2e8e33
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-11T18:13:56-05:00

feat(frontend) update styling

Changes
Diff

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 265acfd2..1743024e 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -96,18 +96,18 @@
<div class="mt-6 pt-6 border-t border-gray-200/70 dark:border-zinc-800/80">
<button
type="button"
- class="group w-full flex items-center justify-between gap-3 px-4 py-3 rounded-2xl bg-gradient-to-br from-blue-500/[0.08] via-slate-500/[0.06] to-violet-500/[0.08] dark:from-blue-500/15 dark:via-zinc-800/40 dark:to-violet-500/15 hover:from-blue-500/15 hover:to-violet-500/15 dark:hover:from-blue-500/25 dark:hover:to-violet-500/25 border border-gray-200/80 dark:border-zinc-700/80 transition-all text-left min-h-[52px]"
+ class="about-action-btn secondary-chip w-full justify-between text-left"
:aria-expanded="showContactSupport"
@click="showContactSupport = !showContactSupport"
>
<div class="flex items-center gap-3 min-w-0">
<span
- class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-500/15 dark:bg-blue-500/25 text-blue-600 dark:text-blue-300 ring-1 ring-blue-500/20"
+ class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300"
>
<v-icon icon="mdi-card-account-details-outline" size="22"></v-icon>
</span>
<span
- class="text-xs font-black uppercase tracking-widest text-gray-800 dark:text-zinc-100 truncate"
+ class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-zinc-100 truncate"
>
{{ $t("about.contact_support_title") }}
</span>
@@ -120,129 +120,105 @@
</button>
<transition name="fade">
- <div
- v-if="showContactSupport"
- class="mt-4 p-5 sm:p-6 rounded-2xl bg-white/70 dark:bg-zinc-950/70 border border-gray-200/90 dark:border-zinc-800 shadow-sm space-y-6"
- >
- <div class="space-y-4">
+ <div v-if="showContactSupport" class="mt-6 flex flex-col gap-6">
+ <div class="flex flex-col gap-3">
<div
- class="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-gray-500 dark:text-zinc-400"
+ class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide flex items-center gap-2"
>
- <v-icon
- icon="mdi-account-circle-outline"
- size="18"
- class="text-blue-500 dark:text-blue-400"
- ></v-icon>
+ <v-icon icon="mdi-account-circle-outline" size="16"></v-icon>
{{ $t("about.contact_developer") }}
</div>
-
- <div class="space-y-3">
- <div>
- <div
- class="text-[9px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ <div class="flex flex-col gap-2">
+ <div
+ class="flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-gray-50 dark:bg-zinc-900/40 border border-gray-100 dark:border-zinc-800"
+ >
+ <router-link
+ :to="{
+ name: 'messages',
+ params: { destinationHash: developerLxmfPrimary },
+ }"
+ class="flex-1 min-w-0 text-sm font-mono text-gray-700 dark:text-zinc-300 hover:text-blue-600 dark:hover:text-blue-400 break-all leading-snug text-left"
+ :title="$t('about.contact_open_messages')"
>
- {{ $t("about.contact_lxmf_address") }}
- </div>
- <div class="flex items-start gap-2">
- <router-link
- :to="{
- name: 'messages',
- params: { destinationHash: developerLxmfPrimary },
- }"
- class="flex-1 min-w-0 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline underline-offset-2 break-all leading-snug text-left"
- :title="$t('about.contact_open_messages')"
- >
- {{ developerLxmfPrimary }}
- </router-link>
- <button
- type="button"
- class="shrink-0 rounded-lg p-2 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
- :aria-label="$t('about.contact_copy_address')"
- @click="
- copyValue(
- developerLxmfPrimary,
- 'about.contact_lxmf_address'
- )
- "
- >
- <v-icon icon="mdi-content-copy" size="18"></v-icon>
- </button>
- </div>
+ {{ developerLxmfPrimary }}
+ </router-link>
+ <button
+ type="button"
+ class="shrink-0 rounded-lg p-1.5 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
+ :aria-label="$t('about.contact_copy_address')"
+ @click="
+ copyValue(developerLxmfPrimary, 'about.contact_lxmf_address')
+ "
+ >
+ <v-icon icon="mdi-content-copy" size="16"></v-icon>
+ </button>
</div>
- <div>
- <div
- class="text-[9px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ <div
+ class="flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-gray-50 dark:bg-zinc-900/40 border border-gray-100 dark:border-zinc-800"
+ >
+ <router-link
+ :to="{
+ name: 'messages',
+ params: { destinationHash: developerLxmfAlternate },
+ }"
+ class="flex-1 min-w-0 text-sm font-mono text-gray-700 dark:text-zinc-300 hover:text-blue-600 dark:hover:text-blue-400 break-all leading-snug text-left"
+ :title="$t('about.contact_open_messages')"
>
- {{ $t("about.contact_alternate") }}
- </div>
- <div class="flex items-start gap-2">
- <router-link
- :to="{
- name: 'messages',
- params: { destinationHash: developerLxmfAlternate },
- }"
- class="flex-1 min-w-0 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline underline-offset-2 break-all leading-snug text-left"
- :title="$t('about.contact_open_messages')"
- >
- {{ developerLxmfAlternate }}
- </router-link>
- <button
- type="button"
- class="shrink-0 rounded-lg p-2 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
- :aria-label="$t('about.contact_copy_address')"
- @click="
- copyValue(developerLxmfAlternate, 'about.contact_alternate')
- "
- >
- <v-icon icon="mdi-content-copy" size="18"></v-icon>
- </button>
- </div>
+ {{ developerLxmfAlternate }}
+ </router-link>
+ <button
+ type="button"
+ class="shrink-0 rounded-lg p-1.5 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
+ :aria-label="$t('about.contact_copy_address')"
+ @click="
+ copyValue(developerLxmfAlternate, 'about.contact_alternate')
+ "
+ >
+ <v-icon icon="mdi-content-copy" size="16"></v-icon>
+ </button>
</div>
</div>
-
<div
- class="text-xs font-semibold text-gray-600 dark:text-zinc-400 bg-gray-50 dark:bg-zinc-900/50 p-3 rounded-xl border border-gray-200/90 dark:border-zinc-800 flex items-start gap-2.5"
+ class="text-xs text-gray-500 dark:text-zinc-400 bg-gray-50 dark:bg-zinc-900/40 p-3 rounded-xl border border-gray-100 dark:border-zinc-800 flex items-start gap-2"
>
<v-icon
icon="mdi-information-outline"
- size="18"
- class="text-blue-500 dark:text-blue-400 shrink-0 mt-0.5"
+ size="16"
+ class="shrink-0 mt-0.5"
></v-icon>
<span>{{ $t("about.contact_propagation_hint") }}</span>
</div>
</div>
- <div class="border-t border-gray-100 dark:border-zinc-800/90 pt-2"></div>
+ <div class="border-t border-gray-100 dark:border-zinc-800/90"></div>
- <div class="space-y-4">
+ <div class="flex flex-col gap-3">
<div
- class="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-gray-500 dark:text-zinc-400"
+ class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide flex items-center gap-2"
>
- <v-icon
- icon="mdi-hand-heart"
- size="18"
- class="text-blue-500 dark:text-blue-400"
- ></v-icon>
+ <v-icon icon="mdi-hand-heart" size="16"></v-icon>
{{ $t("about.donate_label") }}
</div>
<div>
<div
- class="text-[9px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide mb-1.5"
>
{{ $t("about.donate_monero_label") }}
</div>
- <div class="flex items-start gap-2">
+ <div
+ class="flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-gray-50 dark:bg-zinc-900/40 border border-gray-100 dark:border-zinc-800"
+ >
<span
- class="flex-1 min-w-0 text-sm font-medium text-gray-800 dark:text-zinc-200 break-all leading-snug select-all"
+ class="flex-1 min-w-0 text-sm font-mono text-gray-700 dark:text-zinc-300 break-all leading-snug select-all"
>{{ moneroDonateAddress }}</span
>
<button
type="button"
- class="shrink-0 rounded-lg p-2 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
+ class="shrink-0 rounded-lg p-1.5 text-gray-500 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-zinc-800/80 transition-colors"
:aria-label="$t('about.donate_copy_monero')"
@click="copyValue(moneroDonateAddress, 'about.donate_monero_label')"
>
- <v-icon icon="mdi-content-copy" size="18"></v-icon>
+ <v-icon icon="mdi-content-copy" size="16"></v-icon>
</button>
</div>
</div>
@@ -252,12 +228,12 @@
href="https://ko-fi.com/quad4"
target="_blank"
rel="noopener noreferrer"
- class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/70 hover:bg-blue-500/[0.06] hover:border-blue-500/25 dark:hover:bg-zinc-800/80 text-gray-800 dark:text-zinc-100 text-xs font-bold uppercase tracking-wide min-h-[44px] transition-colors"
+ class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900/40 hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-800 dark:text-zinc-100 text-xs font-semibold transition-colors"
>
<v-icon
icon="mdi-coffee"
size="18"
- class="text-blue-600 dark:text-blue-400"
+ class="text-gray-500 dark:text-zinc-400"
></v-icon>
{{ $t("about.donate_kofi") }}
</a>
@@ -265,12 +241,12 @@
href="https://buymeacoffee.com/quad4"
target="_blank"
rel="noopener noreferrer"
- class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/70 hover:bg-blue-500/[0.06] hover:border-blue-500/25 dark:hover:bg-zinc-800/80 text-gray-800 dark:text-zinc-100 text-xs font-bold uppercase tracking-wide min-h-[44px] transition-colors"
+ class="inline-flex flex-1 items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900/40 hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-800 dark:text-zinc-100 text-xs font-semibold transition-colors"
>
<v-icon
icon="mdi-cup"
size="18"
- class="text-blue-600 dark:text-blue-400"
+ class="text-gray-500 dark:text-zinc-400"
></v-icon>
{{ $t("about.donate_buymeacoffee") }}
</a>
@@ -987,6 +963,19 @@
</div>
</div>
+ <div
+ v-if="autoBackups.some((b) => b.name.includes('SUSPICIOUS'))"
+ class="mt-4 p-3 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 text-xs text-amber-700 dark:text-amber-300 flex items-start gap-2"
+ >
+ <v-icon icon="mdi-alert" size="16" class="shrink-0 mt-0.5"></v-icon>
+ <span
+ >Suspicious backups are created when the database size or message count
+ drops unexpectedly compared to the last known baseline, usually after a
+ crash, corruption, or deletion. They are kept automatically so you can
+ inspect or restore from them.</span
+ >
+ </div>
+
<!-- Backups Pagination -->
<div
v-if="autoBackupsTotal > autoBackupsLimit"
@@ -1072,7 +1061,7 @@ export default {
autoBackups: [],
autoBackupsTotal: 0,
autoBackupsOffset: 0,
- autoBackupsLimit: 3,
+ autoBackupsLimit: 4,
electronVersion: null,
chromeVersion: null,
nodeVersion: null,

diff --git a/meshchatx/src/frontend/components/blocked/BlockedPage.vue b/meshchatx/src/frontend/components/blocked/BlockedPage.vue
index f8d9ac9c..d6948bf9 100644
--- a/meshchatx/src/frontend/components/blocked/BlockedPage.vue
+++ b/meshchatx/src/frontend/components/blocked/BlockedPage.vue
@@ -1,165 +1,183 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
- <div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
- <div
- class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-xs"
- >
- <div class="flex items-center gap-3">
- <div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
- <MaterialDesignIcon icon-name="gavel" class="size-6 text-red-600 dark:text-red-400" />
- </div>
- <div>
- <h1 class="text-xl font-bold text-gray-900 dark:text-white">{{ $t("banishment.title") }}</h1>
- <p class="text-sm text-gray-500 dark:text-gray-400">{{ $t("banishment.description") }}</p>
- </div>
- </div>
-
- <div class="ml-auto flex items-center gap-2 sm:gap-4">
- <div class="relative w-32 sm:w-64 md:w-80">
- <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
- <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ <div
+ class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
+ >
+ <div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4 min-w-0">
+ <div class="space-y-0 w-full max-w-6xl xl:max-w-7xl mx-auto min-w-0">
+ <div
+ class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"
+ >
+ <div class="min-w-0 space-y-1">
+ <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
+ {{ $t("banishment.title") }}
+ </h1>
+ <p class="text-sm text-gray-600 dark:text-gray-400">
+ {{ $t("banishment.description") }}
+ </p>
+ </div>
+ <div class="flex items-center gap-2 sm:shrink-0">
+ <div class="relative flex-1 sm:w-64 lg:w-80">
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="absolute left-3 top-1/2 -translate-y-1/2 size-5 shrink-0 text-gray-400 pointer-events-none z-10"
+ />
+ <input
+ v-model="searchQuery"
+ type="text"
+ class="input-field pl-11!"
+ :placeholder="$t('banishment.search_placeholder')"
+ @input="onSearchInput"
+ />
+ </div>
+ <button
+ type="button"
+ class="secondary-chip p-2.5!"
+ :title="$t('common.refresh')"
+ @click="loadBlockedDestinations"
+ >
+ <MaterialDesignIcon
+ icon-name="refresh"
+ class="size-5"
+ :class="{ 'animate-spin-reverse': isLoading }"
+ />
+ </button>
</div>
- <input
- v-model="searchQuery"
- type="text"
- class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
- :placeholder="$t('banishment.search_placeholder')"
- @input="onSearchInput"
- />
</div>
- <button
- class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
- :title="$t('common.refresh')"
- @click="loadBlockedDestinations"
- >
- <MaterialDesignIcon
- icon-name="refresh"
- class="size-6"
- :class="{ 'animate-spin-reverse': isLoading }"
- />
- </button>
- </div>
- </div>
-
- <div class="flex-1 overflow-y-auto p-4 md:p-6">
- <div
- v-if="isLoading && filteredBlockedIdentities.length === 0"
- class="flex flex-col items-center justify-center h-64"
- >
- <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
- <p class="text-gray-500 dark:text-gray-400">{{ $t("banishment.loading_items") }}</p>
- </div>
- <div
- v-else-if="filteredBlockedIdentities.length === 0"
- class="flex flex-col items-center justify-center h-64 text-center"
- >
- <div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
- <MaterialDesignIcon icon-name="check-circle" class="size-12" />
- </div>
- <h3 class="text-lg font-medium text-gray-900 dark:text-white">{{ $t("banishment.no_items") }}</h3>
- <p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
- {{ searchQuery ? $t("nomadnet.no_search_results_peers") : $t("nomadnet.no_announces_yet") }}
- </p>
- </div>
+ <template v-if="isLoading && filteredBlockedIdentities.length === 0">
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4 sm:py-6">
+ <div
+ v-for="i in 5"
+ :key="'skel-' + i"
+ class="banishment-card overflow-hidden p-5 space-y-4 min-w-0"
+ >
+ <div class="flex items-start gap-3">
+ <div class="size-10 rounded-xl bg-gray-200 dark:bg-zinc-700 animate-pulse shrink-0" />
+ <div class="flex-1 min-w-0 space-y-2">
+ <div class="h-4 w-28 bg-gray-200 dark:bg-zinc-700 rounded-sm animate-pulse" />
+ <div class="h-3 w-44 bg-gray-100 dark:bg-zinc-800 rounded-sm animate-pulse" />
+ </div>
+ </div>
+ <div class="space-y-1.5">
+ <div class="h-3 w-20 bg-gray-100 dark:bg-zinc-800 rounded-sm animate-pulse" />
+ <div class="h-8 bg-gray-100 dark:bg-zinc-800 rounded-lg animate-pulse" />
+ </div>
+ <div class="h-9 bg-gray-100 dark:bg-zinc-800 rounded-xl animate-pulse" />
+ </div>
+ </div>
+ </template>
- <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
- v-for="identity in filteredBlockedIdentities"
- :key="identity.identity_hash"
- class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
+ v-else-if="filteredBlockedIdentities.length === 0"
+ class="flex flex-col items-center justify-center py-16 sm:py-20 text-center"
>
- <div class="p-5">
- <div class="flex items-start justify-between mb-4">
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-2 mb-2">
- <div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg shrink-0">
- <MaterialDesignIcon
- icon-name="account-off"
- class="size-5 text-red-600 dark:text-red-400"
- />
- </div>
- <div class="min-w-0 flex-1">
- <div class="flex items-center gap-2 mb-1 flex-wrap">
- <h4
- class="text-base font-semibold text-gray-900 dark:text-white wrap-break-word"
- :title="identity.display_name"
- >
- {{ identity.display_name || $t("call.unknown") }}
- </h4>
- <span
- v-if="identity.is_node"
- class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-sm"
- >
- {{ $t("banishment.node") }}
- </span>
- <span
- v-else
- class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-sm"
- >
- {{ $t("banishment.user") }}
- </span>
- <span
- v-if="identity.is_rns_blackholed"
- class="px-2 py-0.5 text-xs font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 rounded-sm border border-zinc-200 dark:border-zinc-700"
- title="Blackholed at Reticulum transport layer"
- >
- RNS Blackhole
- </span>
- </div>
- <p
- class="text-xs text-gray-500 dark:text-gray-400 font-mono break-all mt-1"
- :title="identity.identity_hash"
+ <div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
+ <MaterialDesignIcon icon-name="check-circle" class="size-12" />
+ </div>
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
+ {{ $t("banishment.no_items") }}
+ </h3>
+ <p class="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto mt-1">
+ {{ searchQuery ? $t("nomadnet.no_search_results_peers") : $t("nomadnet.no_announces_yet") }}
+ </p>
+ </div>
+
+ <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4 sm:py-6">
+ <div
+ v-for="identity in filteredBlockedIdentities"
+ :key="identity.identity_hash"
+ class="banishment-card overflow-hidden group min-w-0"
+ >
+ <div class="p-5 space-y-4">
+ <div class="flex items-start gap-3">
+ <div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-xl shrink-0">
+ <MaterialDesignIcon
+ icon-name="account-off"
+ class="size-5 text-red-600 dark:text-red-400"
+ />
+ </div>
+ <div class="flex-1 min-w-0">
+ <div class="flex flex-wrap items-center gap-1.5 mb-1">
+ <h3
+ class="text-sm font-bold text-gray-900 dark:text-white truncate max-w-full"
+ :title="identity.display_name || $t('call.unknown')"
+ >
+ {{ identity.display_name || $t("call.unknown") }}
+ </h3>
+ <span
+ v-if="identity.is_node"
+ class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-[10px] font-bold uppercase tracking-wider shrink-0"
>
- {{ identity.identity_hash }}
- </p>
+ {{ $t("banishment.node") }}
+ </span>
+ <span
+ v-else
+ class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 text-[10px] font-bold uppercase tracking-wider shrink-0"
+ >
+ {{ $t("banishment.user") }}
+ </span>
+ <span
+ v-if="identity.is_rns_blackholed"
+ class="px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[10px] font-bold uppercase tracking-wider border border-zinc-200 dark:border-zinc-700 shrink-0"
+ title="Blackholed at Reticulum transport layer"
+ >
+ RNS Blackhole
+ </span>
</div>
+ <p
+ class="text-xs font-mono text-gray-500 dark:text-gray-400 truncate"
+ :title="identity.identity_hash"
+ >
+ {{ identity.identity_hash }}
+ </p>
</div>
+ </div>
- <!-- Blocked destination hashes -->
- <div v-if="identity.blocked_destinations.length > 0" class="mb-2">
- <p class="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
- {{ $t("banishment.blocked_destinations") }}
- </p>
- <div class="space-y-1">
- <div
- v-for="dest in identity.blocked_destinations"
- :key="dest.destination_hash"
- class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-50 dark:bg-zinc-800 px-2 py-1 rounded"
+ <div v-if="identity.blocked_destinations.length > 0">
+ <p
+ class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2 uppercase tracking-wide"
+ >
+ {{ $t("banishment.blocked_destinations") }}
+ </p>
+ <div class="space-y-1">
+ <div
+ v-for="dest in identity.blocked_destinations"
+ :key="dest.destination_hash"
+ class="flex items-center justify-between gap-2 px-3 py-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg text-xs"
+ >
+ <span
+ class="font-mono text-gray-500 dark:text-gray-400 truncate min-w-0"
+ :title="dest.destination_hash"
>
- <span class="break-all">{{ dest.destination_hash }}</span>
- <span
- v-if="dest.created_at"
- class="shrink-0 ml-2 text-gray-400 dark:text-zinc-500"
- >
- {{ formatTimeAgo(dest.created_at) }}
- </span>
- </div>
+ {{ dest.destination_hash }}
+ </span>
+ <span v-if="dest.created_at" class="shrink-0 text-gray-400 dark:text-zinc-500">
+ {{ formatTimeAgo(dest.created_at) }}
+ </span>
</div>
</div>
+ </div>
- <div
- v-if="identity.rns_reason"
- class="text-xs italic text-zinc-500 dark:text-zinc-400 mb-2"
- >
- "{{ identity.rns_reason }}"
- </div>
- <div
- v-if="identity.rns_source"
- class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate mb-1"
- >
- Source: {{ identity.rns_source }}
- </div>
+ <div
+ v-if="identity.rns_reason"
+ class="text-xs italic text-zinc-500 dark:text-zinc-400 leading-relaxed"
+ >
+ &ldquo;{{ identity.rns_reason }}&rdquo;
+ </div>
+ <div
+ v-if="identity.rns_source"
+ class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate"
+ >
+ Source: {{ identity.rns_source }}
</div>
+
+ <button class="primary-chip w-full justify-center" @click="onUnblock(identity)">
+ <MaterialDesignIcon icon-name="check-circle" class="size-4" />
+ <span>{{ $t("banishment.lift_banishment") }}</span>
+ </button>
</div>
- <button
- class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors font-medium"
- @click="onUnblock(identity)"
- >
- <MaterialDesignIcon icon-name="check-circle" class="size-5" />
- <span>{{ $t("banishment.lift_banishment") }}</span>
- </button>
</div>
</div>
</div>
@@ -212,11 +230,9 @@ export default {
async loadBlockedDestinations() {
this.isLoading = true;
try {
- // Load local blocked destinations
const response = await window.api.get("/api/v1/blocked-destinations");
const blockedHashes = response.data.blocked_destinations || [];
- // Load Reticulum blackholed identities
let reticulumBlackholed = {};
try {
const rnsResponse = await window.api.get("/api/v1/reticulum/blackhole");
@@ -243,7 +259,6 @@ export default {
return identityMap[identityHash];
};
- // Process local blocked destinations
const processBlockedHash = async (blocked) => {
const hash = blocked.destination_hash;
let identityHash = hash;
@@ -280,7 +295,6 @@ export default {
await Promise.all(blockedHashes.map((blocked) => processBlockedHash(blocked)));
- // Process Reticulum blackholed identities
for (const [hash, info] of Object.entries(reticulumBlackholed)) {
const identity = ensureIdentity(hash);
identity.is_rns_blackholed = true;
@@ -288,7 +302,6 @@ export default {
identity.rns_reason = info.reason || null;
identity.rns_until = info.until || null;
- // Try to look up display name from announces
if (!identity.display_name) {
try {
const announceResponse = await window.api.get("/api/v1/announces", {
@@ -329,7 +342,6 @@ export default {
}
try {
- // Use the first blocked destination hash, or fall back to identity hash
const targetHash =
identity.blocked_destinations.length > 0
? identity.blocked_destinations[0].destination_hash
@@ -350,3 +362,16 @@ export default {
},
};
</script>
+
+<style scoped>
+@reference "../../style.css";
+.banishment-card {
+ @apply bg-white dark:bg-zinc-900/95 border border-gray-200/70 dark:border-zinc-800/80 rounded-2xl shadow-sm transition-all duration-200;
+}
+.banishment-card:hover {
+ @apply shadow-md border-gray-300/80 dark:border-zinc-700/80;
+}
+.input-field {
+ @apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-3 text-gray-900 dark:text-gray-100 transition;
+}
+</style>

diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
index 8b07da1f..5ada5b88 100644
--- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue
+++ b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
@@ -84,7 +84,7 @@
</div>
<div v-else class="divide-y divide-gray-100 dark:divide-zinc-800">
<div
- v-for="contact in contacts"
+ v-for="contact in mergedContacts"
:key="contact.id"
class="group flex cursor-default items-center gap-3 px-1 py-3 transition-colors hover:bg-gray-50/80 dark:hover:bg-zinc-900/70"
@contextmenu.prevent="openContextMenu($event, contact)"
@@ -106,8 +106,30 @@
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">
{{ contact.name }}
</div>
- <div class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all">
- {{ contact.lxmf_address || contact.remote_identity_hash }}
+ <div class="flex flex-col gap-0.5">
+ <div v-if="contact.remote_destination_hash" class="flex items-center gap-1.5">
+ <MaterialDesignIcon
+ icon-name="message-text-outline"
+ class="size-4 text-blue-500 dark:text-blue-400 shrink-0"
+ />
+ <span class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all">{{
+ contact.remote_destination_hash
+ }}</span>
+ </div>
+ <div v-if="contact.remote_telephony_hash" class="flex items-center gap-1.5">
+ <MaterialDesignIcon
+ icon-name="phone-outline"
+ class="size-4 text-green-600 dark:text-green-400 shrink-0"
+ />
+ <span class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all">{{
+ contact.remote_telephony_hash
+ }}</span>
+ </div>
+ <span
+ v-if="!contact.remote_destination_hash && !contact.remote_telephony_hash"
+ class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all"
+ >{{ contact.lxmf_address || contact.remote_identity_hash }}</span
+ >
</div>
</div>
<div
@@ -475,6 +497,24 @@ export default {
hasMoreContacts() {
return this.contacts.length < this.totalContactsCount;
},
+ mergedContacts() {
+ const map = new Map();
+ for (const c of this.contacts) {
+ const key = c.name?.toLowerCase() || "";
+ if (!map.has(key)) {
+ map.set(key, { ...c });
+ } else {
+ const existing = map.get(key);
+ // Merge fields so both LXMF and LXST addresses are visible
+ existing.lxmf_address = existing.lxmf_address || c.lxmf_address;
+ existing.lxst_address = existing.lxst_address || c.lxst_address;
+ existing.remote_destination_hash = existing.remote_destination_hash || c.remote_destination_hash;
+ existing.remote_telephony_hash = existing.remote_telephony_hash || c.remote_telephony_hash;
+ existing.remote_identity_hash = existing.remote_identity_hash || c.remote_identity_hash;
+ }
+ }
+ return Array.from(map.values());
+ },
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -669,7 +709,6 @@ export default {
await window.api.post("/api/v1/telephone/contacts", {
name: this.newContactName?.trim() || `Contact ${destinationHash.slice(0, 8)}`,
- remote_identity_hash: destinationHash,
lxmf_address: destinationHash,
});
ToastUtils.success(this.$t("contacts.contact_added"));
@@ -704,9 +743,17 @@ export default {
async removeContact(contact) {
this.closeContextMenu();
if (!contact?.id) return;
- if (!window.confirm(this.$t("contacts.remove_contact_confirm"))) return;
+ const duplicates = this.contacts.filter((c) => c.name === contact.name && c.id !== contact.id);
+ const confirmMsg =
+ duplicates.length > 0
+ ? `${this.$t("contacts.remove_contact_confirm")}\n\n(${duplicates.length} additional duplicate${duplicates.length > 1 ? "s" : ""} named "${contact.name}" will also be removed)`
+ : this.$t("contacts.remove_contact_confirm");
+ if (!window.confirm(confirmMsg)) return;
try {
- await window.api.delete(`/api/v1/telephone/contacts/${contact.id}`);
+ const ids = [contact.id, ...duplicates.map((c) => c.id)];
+ for (const id of ids) {
+ await window.api.delete(`/api/v1/telephone/contacts/${id}`);
+ }
ToastUtils.success(this.$t("contacts.contact_removed"));
await this.getContacts();
} catch {
@@ -719,13 +766,19 @@ export default {
const name = await DialogUtils.prompt(this.$t("contacts.enter_contact_name"), contact.name);
if (name == null || name === contact.name) return;
try {
- await window.api.patch(`/api/v1/telephone/contacts/${contact.id}`, { name });
- const destHash =
- contact.remote_destination_hash || contact.lxmf_address || contact.remote_identity_hash;
- if (destHash && name.length > 0) {
- await window.api.post(`/api/v1/destination/${destHash}/custom-display-name/update`, {
- display_name: name,
- });
+ const duplicates = this.contacts.filter((c) => c.name === contact.name && c.id !== contact.id);
+ const ids = [contact.id, ...duplicates.map((c) => c.id)];
+ for (const id of ids) {
+ await window.api.patch(`/api/v1/telephone/contacts/${id}`, { name });
+ }
+ const allContacts = [contact, ...duplicates];
+ for (const c of allContacts) {
+ const destHash = c.remote_destination_hash || c.lxmf_address || c.remote_identity_hash;
+ if (destHash && name.length > 0) {
+ await window.api.post(`/api/v1/destination/${destHash}/custom-display-name/update`, {
+ display_name: name,
+ });
+ }
}
ToastUtils.success(this.$t("contacts.contact_updated"));
await this.getContacts();

diff --git a/meshchatx/src/frontend/components/interfaces/Interface.vue b/meshchatx/src/frontend/components/interfaces/Interface.vue
index 28441483..3e3f6cc7 100644
--- a/meshchatx/src/frontend/components/interfaces/Interface.vue
+++ b/meshchatx/src/frontend/components/interfaces/Interface.vue
@@ -84,7 +84,8 @@
class="text-blue-500 hover:underline"
@click="onIFACSignatureClick(iface._stats.ifac_signature)"
>
- {{ iface._stats.ifac_signature.slice(0, 8) }}…{{ iface._stats.ifac_signature.slice(-8) }}
+ <span class="font-mono">{{ iface._stats.ifac_signature.slice(0, 8) }}</span
+ >…<span class="font-mono">{{ iface._stats.ifac_signature.slice(-8) }}</span>
</button>
</div>
</div>
@@ -258,6 +259,9 @@ export default {
if (this.iface.type === "AutoInterface") {
return "Auto-detect Ethernet and Wi-Fi peers";
}
+ if (this.iface.type === "BackboneInterface") {
+ return "Backbone (IFAC tunnel)";
+ }
return this.iface.description || "Custom interface";
},
statusChipClass() {

diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 22678f6e..85a168b1 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -80,7 +80,7 @@
@click="refreshCommunityInterfaces"
>
<MaterialDesignIcon
- icon-name="refresh"
+ icon-name="download"
class="w-4 h-4"
:class="{ 'animate-spin-reverse': refreshingCommunityInterfaces }"
/>

diff --git a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
index 99196240..71a1ec0d 100644
--- a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
+++ b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
@@ -1,39 +1,45 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
- <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
- <div
- class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
- >
- <div class="space-y-4 w-full max-w-5xl mx-auto">
- <div class="glass-card space-y-5">
- <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
- <div class="space-y-2">
- <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- Reticulum Content Serving
- </div>
- <div class="text-2xl font-semibold text-gray-900 dark:text-white">Mesh Server</div>
- <div class="text-sm text-gray-600 dark:text-gray-300">
- Serve Micron pages and files directly over the Reticulum mesh. Each server gets its own
- identity and destination address.
- </div>
+ <div
+ class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
+ >
+ <div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4 min-w-0">
+ <div class="space-y-0 w-full max-w-6xl xl:max-w-7xl mx-auto min-w-0">
+ <div
+ class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 flex flex-col lg:flex-row lg:items-center justify-between gap-4"
+ >
+ <div class="space-y-1">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ Reticulum Content Serving
</div>
- <button
- type="button"
- class="primary-chip px-4 py-2 text-sm shrink-0"
- @click="showCreateDialog = true"
- >
- <MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
- Create Node
- </button>
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">Mesh Server</h1>
+ <p class="text-sm text-gray-600 dark:text-gray-300 max-w-2xl">
+ Serve Micron pages and files directly over the Reticulum mesh. Each server gets its own
+ identity and destination address.
+ </p>
</div>
+ <button
+ type="button"
+ class="primary-chip px-4 py-2 text-sm shrink-0"
+ @click="showCreateDialog = true"
+ >
+ <MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
+ Create Node
+ </button>
</div>
- <div v-if="loading" class="glass-card text-center py-12">
+ <div
+ v-if="loading"
+ class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-8 sm:py-12 text-center"
+ >
<div class="text-gray-500 dark:text-gray-400">Loading nodes...</div>
</div>
- <div v-else-if="nodes.length === 0" class="glass-card text-center py-12">
+ <div
+ v-else-if="nodes.length === 0"
+ class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-8 sm:py-12 text-center"
+ >
<MaterialDesignIcon icon-name="server-network" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<div class="text-gray-600 dark:text-gray-400 mb-2">No mesh servers yet</div>
<div class="text-sm text-gray-500 dark:text-gray-500">
@@ -41,64 +47,66 @@
</div>
</div>
- <div v-else class="space-y-3">
+ <div v-else class="w-full divide-y divide-gray-200/60 dark:divide-zinc-800/60">
<div
v-for="node in nodes"
:key="node.node_id"
- class="glass-card space-y-4 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition"
+ class="py-3 sm:py-4 space-y-2 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors rounded-lg -mx-3 sm:-mx-4 px-3 sm:px-4"
@click="selectNode(node)"
>
- <div class="flex items-center justify-between">
- <div class="flex items-center gap-3">
+ <div class="flex items-center justify-between gap-3">
+ <div class="flex items-center gap-3 min-w-0">
<div
- class="w-3 h-3 rounded-full"
+ class="w-3 h-3 rounded-full shrink-0"
:class="node.running ? 'bg-green-500' : 'bg-gray-400'"
></div>
- <div>
- <div class="font-semibold text-gray-900 dark:text-white">{{ node.name }}</div>
+ <div class="min-w-0">
+ <div class="font-semibold text-gray-900 dark:text-white truncate">
+ {{ node.name }}
+ </div>
<div
v-if="node.destination_hash"
- class="text-xs font-mono text-gray-500 dark:text-gray-400"
+ class="text-xs font-mono text-gray-500 dark:text-gray-400 truncate"
>
{{ node.destination_hash }}
</div>
</div>
</div>
- <div class="flex items-center gap-2">
- <span class="text-xs text-gray-500 dark:text-gray-400">
- {{ node.pages.length }} pages, {{ node.files.length }} files
+ <div class="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
+ <span class="text-xs text-gray-500 dark:text-gray-400 mr-1">
+ {{ node.pages.length }}p / {{ node.files.length }}f
</span>
<button
v-if="!node.running"
- class="primary-chip py-1! px-3! text-xs!"
+ class="primary-chip py-1! px-2.5! text-xs!"
@click.stop="startNode(node.node_id)"
>
Start
</button>
<button
v-else
- class="secondary-chip py-1! px-3! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
+ class="secondary-chip py-1! px-2.5! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
@click.stop="stopNode(node.node_id)"
>
Stop
</button>
<button
v-if="node.running"
- class="secondary-chip py-1! px-3! text-xs!"
+ class="secondary-chip py-1! px-2.5! text-xs!"
@click.stop="announceNode(node.node_id)"
>
Announce
</button>
<button
v-if="node.running && node.destination_hash"
- class="secondary-chip py-1! px-3! text-xs!"
+ class="secondary-chip py-1! px-2.5! text-xs!"
@click.stop="viewNode(node)"
>
<MaterialDesignIcon icon-name="eye" class="w-3.5 h-3.5" />
View
</button>
<button
- class="secondary-chip py-1! px-3! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
+ class="secondary-chip py-1! px-2.5! text-xs! text-red-500! hover:bg-red-50! dark:hover:bg-red-900/20!"
@click.stop="deleteNode(node.node_id)"
>
<MaterialDesignIcon icon-name="delete" class="w-3.5 h-3.5" />
@@ -108,19 +116,22 @@
<div
v-if="node.stats || node.running"
- class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400"
+ class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400 pl-6"
>
<span v-if="node.running">{{ formatMeshUptime(node.uptime_seconds) }} uptime</span>
- <span>{{ node.unique_connections ?? 0 }} unique connections</span>
- <span v-if="node.stats">{{ node.stats.pages_served }} pages served</span>
- <span v-if="node.stats">{{ node.stats.files_served }} files served</span>
+ <span>{{ node.unique_connections ?? 0 }} connections</span>
+ <span v-if="node.stats">{{ node.stats.pages_served }} pages</span>
+ <span v-if="node.stats">{{ node.stats.files_served }} files</span>
<span v-if="node.stats">{{ node.stats.links_established }} links</span>
</div>
</div>
</div>
<!-- Selected Node Detail -->
- <div v-if="selectedNode" class="glass-card space-y-5">
+ <div
+ v-if="selectedNode"
+ class="w-full py-4 sm:py-6 space-y-4 border-t border-gray-200/60 dark:border-zinc-800/60"
+ >
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ selectedNode.name }}
@@ -154,13 +165,13 @@
</div>
<!-- Tabs: Pages / Files -->
- <div class="flex gap-2 border-b border-gray-200 dark:border-zinc-700">
+ <div class="flex gap-2 border-b border-gray-200/60 dark:border-zinc-800/60">
<button
:class="[
detailTab === 'pages'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400',
- 'px-4 py-2 font-semibold transition text-sm',
+ 'px-4 py-2 font-semibold transition text-sm -mb-px',
]"
@click="detailTab = 'pages'"
>
@@ -171,7 +182,7 @@
detailTab === 'files'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400',
- 'px-4 py-2 font-semibold transition text-sm',
+ 'px-4 py-2 font-semibold transition text-sm -mb-px',
]"
@click="detailTab = 'files'"
>

diff --git a/meshchatx/src/frontend/components/tools/RepositoryServerPage.vue b/meshchatx/src/frontend/components/tools/RepositoryServerPage.vue
index 6c0ad2c0..affe6bda 100644
--- a/meshchatx/src/frontend/components/tools/RepositoryServerPage.vue
+++ b/meshchatx/src/frontend/components/tools/RepositoryServerPage.vue
@@ -1,11 +1,13 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
- <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
- <div class="flex-1 overflow-y-auto w-full pb-[max(1rem,env(safe-area-inset-bottom))]">
- <div class="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto w-full space-y-4 min-w-0">
+ <div
+ class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
+ >
+ <div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4 min-w-0">
+ <div class="space-y-0 w-full max-w-6xl xl:max-w-7xl mx-auto min-w-0">
<div
- class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-200 dark:border-zinc-800 pb-4"
+ class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 flex flex-wrap items-start justify-between gap-3"
>
<div class="flex items-start gap-3 min-w-0">
<div
@@ -13,14 +15,14 @@
>
<MaterialDesignIcon icon-name="package-variant" class="size-6" />
</div>
- <div class="min-w-0">
+ <div class="min-w-0 space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<h1 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white tracking-tight">
{{ $t("tools.repository_server.title") }}
</h1>
- <p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 mt-1 max-w-2xl">
+ <p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 max-w-2xl">
{{ $t("tools.repository_server.subtitle") }}
</p>
</div>
@@ -28,7 +30,7 @@
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
- class="inline-flex items-center justify-center p-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-gray-600 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
+ class="secondary-chip p-2!"
:title="$t('tools.repository_server.refresh_bundled_tooltip')"
:disabled="refreshing"
@click="refreshBundled"
@@ -37,7 +39,7 @@
</button>
<RouterLink
to="/tools"
- class="inline-flex items-center gap-2 text-sm text-sky-600 dark:text-sky-300 hover:underline"
+ class="inline-flex items-center gap-1 text-sm text-sky-600 dark:text-sky-300 hover:underline"
>
<MaterialDesignIcon icon-name="arrow-left" class="size-4" />
{{ $t("tools.back_to_tools") }}
@@ -47,7 +49,7 @@
<div
v-if="refreshing"
- class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-3 py-2.5 space-y-1.5"
+ class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 space-y-2"
>
<div class="h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
@@ -60,9 +62,7 @@
</p>
</div>
- <div
- class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
- >
+ <div class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 space-y-3">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("tools.repository_server.http_heading") }}
</h2>
@@ -140,62 +140,70 @@
</div>
</div>
- <div
- class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
- >
+ <div class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6 space-y-3">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("tools.repository_server.upload_heading") }}
</h2>
- <label
- class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-600 text-white text-sm font-medium cursor-pointer hover:bg-sky-700 transition-colors"
- >
- <MaterialDesignIcon icon-name="upload" class="size-4" />
- {{ $t("tools.repository_server.choose_file") }}
- <input type="file" class="hidden" @change="onUpload" />
- </label>
- <p v-if="lastUploadError" class="text-xs text-red-600 dark:text-red-400">{{ lastUploadError }}</p>
+ <div class="flex flex-wrap items-center gap-3">
+ <label
+ class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-600 text-white text-sm font-medium cursor-pointer hover:bg-sky-700 transition-colors"
+ >
+ <MaterialDesignIcon icon-name="upload" class="size-4" />
+ {{ $t("tools.repository_server.choose_file") }}
+ <input type="file" class="hidden" @change="onUpload" />
+ </label>
+ <p v-if="lastUploadError" class="text-xs text-red-600 dark:text-red-400">
+ {{ lastUploadError }}
+ </p>
+ </div>
</div>
<div
v-if="status?.last_refresh_failed && Object.keys(status.last_refresh_failed).length"
- class="rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-200"
- >
- <div class="font-semibold mb-1">{{ $t("tools.repository_server.refresh_partial") }}</div>
- <ul class="list-disc pl-4 space-y-1">
- <li v-for="(msg, pkg) in status.last_refresh_failed" :key="pkg">
- <span class="font-mono">{{ pkg }}</span
- >: {{ msg }}
- </li>
- </ul>
- </div>
-
- <div
- class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden"
+ class="w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6"
>
<div
- class="px-4 py-3 border-b border-gray-100 dark:border-zinc-800 flex justify-between items-center"
+ class="rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-200"
>
+ <div class="font-semibold mb-1">{{ $t("tools.repository_server.refresh_partial") }}</div>
+ <ul class="list-disc pl-4 space-y-1">
+ <li v-for="(msg, pkg) in status.last_refresh_failed" :key="pkg">
+ <span class="font-mono">{{ pkg }}</span
+ >: {{ msg }}
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="w-full py-4 sm:py-6 space-y-3">
+ <div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("tools.repository_server.files_heading") }}
</h2>
<span class="text-xs text-gray-500">{{ entries.length }}</span>
</div>
- <div v-if="loading" class="p-6 text-center text-sm text-gray-500">
+ <div v-if="loading" class="text-center text-sm text-gray-500 py-6">
{{ $t("common.loading") }}
</div>
- <div v-else-if="entries.length === 0" class="p-6 text-center text-sm text-gray-500">
+ <div v-else-if="entries.length === 0" class="text-center text-sm text-gray-500 py-6">
{{ $t("tools.repository_server.empty") }}
</div>
<table v-else class="w-full text-left text-xs">
- <thead class="bg-gray-50 dark:bg-zinc-900/50 text-gray-500 uppercase tracking-wide">
+ <thead
+ class="text-gray-500 uppercase tracking-wide border-b border-gray-200/60 dark:border-zinc-800/60"
+ >
<tr>
- <th class="px-4 py-2">{{ $t("tools.repository_server.col_name") }}</th>
- <th class="px-4 py-2">{{ $t("tools.repository_server.col_source") }}</th>
- <th class="px-4 py-2 text-right">{{ $t("tools.repository_server.col_size") }}</th>
- <th class="px-4 py-2 w-24"></th>
+ <th class="px-4 py-2 font-semibold">{{ $t("tools.repository_server.col_name") }}</th>
+ <th class="px-4 py-2 font-semibold">{{ $t("tools.repository_server.col_source") }}</th>
+ <th class="px-4 py-2 font-semibold text-right">
+ {{ $t("tools.repository_server.col_size") }}
+ </th>
+ <th class="px-4 py-2 font-semibold w-24"></th>
</tr>
</thead>
- <tbody class="divide-y divide-gray-100 dark:divide-zinc-800 text-gray-800 dark:text-zinc-200">
+ <tbody
+ class="divide-y divide-gray-100 dark:divide-zinc-800/50 text-gray-800 dark:text-zinc-200"
+ >
<tr v-for="row in entries" :key="row.name + row.source">
<td class="px-4 py-2 font-mono break-all">{{ row.name }}</td>
<td class="px-4 py-2">{{ row.source }}</td>


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────